<!doctype html>
<meta charset="UTF-8">
<title>Layout Tests</title>
<!--
Displays web_tests results.
Version control:
This file is version controlled by results.html.version.
Please do NOT bump up the version number while you are still
testing your changes. Bump up the version number when your
change is ready, and it will be uploaded to GCS after that.
-->
<style>
body {
  font-family: sans-serif;
  min-height: 120vh;
}
button {
  margin-top: 4px;
}
input {
  vertical-align: middle;
  margin-top: 0;
  margin-bottom: 0;
}
p, h2, h3, h4 {
  margin: 8px 0 4px 0;
}
a:not([href]) {
  opacity: 0.3;
}
#right-toolbar {
  position: absolute;
  top: 8px;
  right: 0;
  font-size: smaller;
}

.good {
  background-color: rgba(0, 255, 0, 0.1);
}

.bad {
  background-color: rgba(255, 0, 0, 0.1);
}

.popup {
  font-family: sans-serif;
  box-sizing: border-box;
  position: fixed;
  width: 96vw;
  height: 96vh;
  top: 2vh;
  left: 2vw;
  border: 5px solid black;
  background-color: white;
  padding: 16px;
  box-shadow: 0 0 20px;
  overflow: auto;
  z-index: 1;
}
#summary > p {
  margin: 0.2em 0 0 0;
}
#dashboard {
  user-select: none;
}
#report {
  font-family: monospace;
}
#none {
  color: green;
  margin-left: 2em;
  font-size: x-large;
  font-weight: bold;
  font-style: italic;
}
.fix-width {
  display: inline-block;
  width: 7em;
  text-align: right;
  margin-right: 1em;
}
.hidden {
  display: none;
}
.warn {
  color: red;
}
.h-expect {
  margin-left: 1.25em;
}
.expect {
  line-height: 200%;
  cursor: zoom-in;
}
.expect:hover, .expect:focus {
  background-color: #F4F4F4;
}
.expect:focus > .details {
  visibility: visible;
}
.details {
  box-sizing: border-box;
  visibility: hidden;
  display: inline-block;
  position: relative;
  top: 0.2em;
  width: 1em;
  height: 1em;
  border-top: 0.5em solid transparent;
  border-bottom: 0.5em solid transparent;
  border-right: none;
  border-left: 0.5em solid gray;
  margin-right: .25em;
  cursor: pointer;
}
.details.open {
  visibility: visible !important;
  top: 0.5em;
  border-left: 0.5em solid transparent;
  border-right: 0.5em solid transparent;
  border-top: 0.5em solid gray;
  border-bottom: none;
}

.result-frame {
  border: 1px solid gray;
  border-top: 1px solid transparent;
  margin-left: 2.25em;
  margin-right: 2.25em;
  margin-top: 4px;
  margin-bottom: 16px;
}
.result-menu {
  list-style-type: none;
  margin: 0;
  padding: 0;
}
.result-menu li {
  display: inline-block;
  min-width: 100px;
  font-size: larger;
  border: 1px dotted gray;
  border-bottom: 1px solid transparent;
  margin-right: 8px;
}
.result-output iframe {
  width: 100%;
  height: 50vh;
  max-height: 800px;
  border: 0px solid gray;
  resize: both;
  overflow: auto;
}
#filters {
  margin-top: 8px;
}
#filters label {
  font-family: sans-serif;
  font-size: smaller;
}

.flag {
  display: inline-block;
  vertical-align:middle;
  width: 1.2em;
  height: 1.2em;
  border: 1px solid #DDD;
  cursor: default;
  user-select: none;
  margin-right: 8px;
}
.flag.flagged::after {
  content: "⚑";
  user-select: none;
  font-size: x-large;
  position: relative;
  top: -0.1em;
  left: 0.1em;
  line-height: 100%;
  color: gray;
}

#copied {
  color: #4F8A10;
  margin-left: 5px;
}

#flag-toolbar.hidden {
  display: none;
}

.actual-result {
  font-weight: bold;
}

.unexpected-failure {
  color: red;
}

.unexpected-pass {
  color: green;
}

.timing-stats-table {
  text-align: right;
  white-space: nowrap;
}

.timing-stats-table th {
  padding-left: 10px;
}

.download-button {
  display: inline-block;
  width: 15px;
  height: 15px;
  margin-bottom: -2px;
  margin-right: 0px;
  content: url();
}

.stat-bar-count, .stat-bar-time {
  width: 12px;
  height: 8px;
  margin-left: 4px;
}

.stat-bar-count {
  background: blue;
}

.stat-bar-time {
  background: cyan;
}
</style>
<body>
<h3>Test run summary <span id="builder_name"></span> <span class="flag_name"></span></h3>

<div id="right-toolbar">
  <a href="javascript:GUI.showTimingStats()">Timing stats</a>
  &nbsp;
  <a id="help_button" href="javascript:GUI.toggleVisibility('help')">Help</a>
</div>

<div id="timing-stats" class="popup hidden hide-on-esc">
  <button style="position:fixed;right:40px;" onclick="GUI.toggleVisibility('timing-stats')">Close</button>
  <h3>Timing stats</h3>
  <table id="timing-stats-table" class="timing-stats-table"></table>
  <h3>Virtual suites</h3>
  <table id="timing-stats-virtuals" class="timing-stats-table"></table>
</div>

<div id="help" class="popup hidden hide-on-esc">
  <button style="position:fixed;right:40px;" onclick="GUI.toggleVisibility('help')">Close</button>
  <h3>Keyboard navigation</h3>

  <ul>
  <li><b>Space</b> Show full results of the next test. This is the easiest way to navigate, just hit spacebar (and shift space to go back).
  <li><b>Tab</b> to select the next test.
  <li><b>Enter</b> to see test details. This will automatically close other details.
  <li><b>ctrl A</b> to select text of all tests for easy copying.
  <li><b>f</b> to flag/unflag a test.
  <li><b>?</b> to blink error region of a failed image test.
  <li><b>p</b> prints full record of currently selected test to console.
  </ul>
  <p>Modifiers:</p>
  <ul>
  <li><b>Shift</b> hold shift key to keep other details open.
  <li><b>Meta</b> meta means all. Set/unset all flags or open/close all results (max 100).
  </ul>
  <p>This page lets you query and display test results.</p>

  <h3>Querying Results</h3>

  <p>Select the results you are interested in using "Query" buttons.</p>
  <p>Narrow down the results further with "Filter" search box and checkboxes.
  The search string can be a (full or partial) test name or bug number.
  Additionally, the following range specifiers are supported:</p>
  <ul>
  <li>Range of test time duration: 'time:min-' or 'time:min-max' or 'time:-max' in seconds,</li>
  <li>Total pixel difference: 'pixels:min-' or 'pixels:min-max' or 'pixels:-max',</li>
  <li>Maximum channel difference: 'channel_max:min-' or 'channel_max:min-max' or 'channel_max:-max'.</li>
  </ul>


  <h3>Displaying results.</h3>
  <p>You can view the list of matched tests in several result formats.
  Select format that works best for what you are trying to accomplish
  using "format" popup. Following formats are available:</p>
  <h4>1. Plain text</h4>
  <pre>fast/forms/<a href="https://cs.chromium.org/chromium/src/third_party/blink/web_tests/fast/forms/validation-bubble-appearance-rtl-ui.html?q=validation-bubble-appearance-rtl-ui.html&dr">validation-bubble-appearance-rtl-ui.html</a></pre>
  <p>Plain text shows the test path.</p>

  <h4>2. TestExpectations</a></h4>
<pre><a href="#">crbug.com/bug</a> layout/test/path/<a href="#">test.html</a> [ Status ]</pre>
  <p>TestExpectationsshow lines as they'd appear in <a
    href="https://chromium.googlesource.com/chromium/src/+/main/docs/testing/web_test_expectations.md">TestExpectations</a> file.</p>

  <p>The interesting part here is [ Status ]. Inside TestExpectations file, [ Status ]
  can have multiple values, representing all expected results. For example:</p>

<pre>[ Failure Timeout Crash Pass ]</pre>

  <p>Result lines include existing expected values, and make a guess about what the new
  test expectation line should look like by merging together expected and actual
  results. The actual result will be shown in bold, and unexpected actual result will be
  shown in red (unexpected failure) or green (unexpected pass). For example:</p>

  <pre>TestResult(PASS) + TestExpectation(Failure) => [ Failure <span class="actual-result unexpected-pass">Pass</span> ]
TestResult(TIMEOUT CRASH) + TextExpectation(Timeout Failure) => [ Failure <span class="actual-result">Timeout</span> <span class="actual-result unexpected-failure">Crash</span> ]</pre>

  <p>If you are doing a lot of TestExpectation edits, the hope is that this will make
  your job as easy as copy and paste.</p>

  <h4>3. Crash site</h4>
  <pre>
### Crash site: Internals.cpp(3455)
  editing/pasteboard/<a href="https://cs.chromium.org/chromium/src/third_party/blink/web_tests/editing/pasteboard/copy-paste-white-space.html?q=copy-paste-white-space.html&dr">copy-paste-white-space.html</a></pre>
  <p>Crash site groups "Crash" tests with similar stack traces together. For best results, use it while filtering only crashes.</p>
  <h4>4. Text mismatch</h4>
  <pre>
### Text mismatch failure: general text mismatch
  accessibility/dimensions-include-descendants.html
### Text mismatch failure: newlines only
  accessibility/aria-controls-with-tabs.html
### Text mismatch failure: spaces and tabs only
  accessibility/aria-describedby-on-input.html
</pre>
  <p>Text mistmatch groups "Text failure" tests together.
  </p>
  <h4>5. Rebaseline</h4>
    <p>Generates a bash script to rebaseline based on new results. @xiaochengh knows the details on how to use it, and @kojii is
    our one user.</p>
  <h3>Viewing results of a single test</h3>
  <h4>Image results</h4>
  <p>Click on images to zoom in. Select image viewing mode from the popup.</p>
  <p>When viewing images for the first time, red flash highlights enclosing
  rectangle that was colored red in the diff. Diff flash and color eyedropper
  will not be available on file:// urls because of CSP.</p>
  <h4>Text results</h4>
  <p>Different panels show the expected, actual text results and differences.</p>
  <p>For repaint tests, the "repaint" panel visualizes the repaint rectangles.</p>
  <h3>Flagging</h3>
  <p>Tests can be flagged by clicking on that square box on the right hand side. View all flagged tests with "Flagged" filter. "F" is the keyboard shortcut.
  <h3>Bugs</h3>
  <p>If you are unhappy with results, please file a bug, or fix it <a href="https://cs.chromium.org/chromium/src/third_party/blink/tools/blinkpy/web_tests/results.html">here</a>.</p>
  <p><code>window.localStorage.setItem("testLocationOverride", "file://path/to/your/web_tests")</code> is a secret preference to redirect test links to a custom url.</p>
</div>
<div id="summary">
  <p><span class="fix-width">Passed</span><span id="summary_passed"></span></p>
  <p><span class="fix-width">Regressions</span><span id="summary_regressions"></span></p>
  <p><span class="fix-width">Total</span><span id="summary_total"></span></p>
  <p><span class="fix-width">Counts</span><span id="summary_details"></span></p>
</div>
<hr>
<div id="dashboard">
  <div>
    <span class="fix-width">Query</span>
    <button id="button_regressions" onclick="javascript:Query.query('Regressions', Filters.regression, true)">
      Regressions
      <span id="count_regressions"></span>
    </button>
    <button onclick="javascript:Query.query('Unexpected passes', Filters.unexpectedPass, true)">
      Unexpected Pass
      <span id="count_unexpected_pass"></span>
    </button>
    <button onclick="javascript:Query.query('Did not pass', Filters.notpass, true)">
      Did not pass
      <span id="count_testexpectations"></span>
    </button>
    <button onclick="javascript:Query.query('All', Filters.all, true)">
      All
      <span id="count_all"></span>
    </button>
    <button onclick="javascript:Query.query('Flaky', Filters.flaky, true)">
      Flaky
      <span id="count_flaky"></span>
    </button>
    <button onclick="javascript:Query.query('Unexpected Flaky', Filters.unexpectedFlaky, true)">
      Unexpected flaky
      <span id="count_unexpected_flaky"></span>
    </button>
    <button onclick="javascript:Query.query('Flagged', Filters.flagged, true)">
      Flagged
    </button>
    <div id="flag-toolbar" class="hidden">
      <span class="fix-width"></span>
      <button onclick="javascript:Query.query('Flag failures', Filters.flagFailure, true)">
        <span class="flag_name"></span> failures
        <span id="count_flag_failure"></span>
      </button>
      <button onclick="javascript:Query.query('Flag passes', Filters.flagPass, true)">
        <span class="flag_name"></span> passes
        <span id="count_flag_pass"></span>
      </button>
      <button onclick="javascript:Query.query('Flag unexpected passes', Filters.flagUnexpectedPass, true)">
        <span class="flag_name"></span> unexpected passes
        <span id="count_flag_unexpected_pass"></span>
      </button>
    </div>
  </div>
  <div id="filters">
    <span class="fix-width">Filters</span>
    <input id="text-filter" onsearch="Query.filterChanged()" type="search"
           placeholder="[test name | bug] [time:min-max] [pixels:min-max] [channel_max:min-max] &#9166;"
           title="Text filter, see Help for format information.">
    <label id="CRASH"><input type="checkbox">Crash <span></span></label>
    <label id="TIMEOUT"><input type="checkbox">Timeout <span></span></label>
    <label id="TEXT"><input type="checkbox">Text failure <span></span></label>
    <label id="HARNESS"><input type="checkbox">Harness failure <span></span></label>
    <label id="IMAGE"><input type="checkbox">Image failure <span></span></label>
    <label id="IMAGE_TEXT"><input type="checkbox">Image+text failure <span></span></label>
    <label id="AUDIO"><input type="checkbox">Audio failure <span></span></label>
    <label id="SKIP"><input type="checkbox">Skipped <span></span></label>
    <label id="PASS"><input type="checkbox">Pass <span></span></label>
    <label id="LEAK"><input type="checkbox">Leak <span></span></label>
  </div>
</div>

<div id="report_header" style="margin-top:8px">
  <span class="fix-width">Tests shown</span><span id="report_count"></span>
  <span id="report_title" style="font-weight:bold"></span>
  in format:
    <select id="report_format" onchange="Query.generateReport()">
      <option value="plain" selected>Plain text</option>
      <option value="expectation">TestExpectations</option>
      <option value="crashsite">Crash site</option>
      <option value="textmismatch">Text mismatch</option>
      <option value="rebaseline">Rebaseline script</option>
    </select>
  <span style="margin-left: 20px">
    <button id="copy_report" title="Copy the shown/flagged tests to clipboard in the current format"
        onclick="GUI.copyResult(false)" disabled>Copy report</button>
    <button id="copy_test_names" title="Copy the names of the shown/flagged tests to clipboard in a single line for use in command lines."
        onclick="GUI.copyResult(true)" disabled>Copy test names</button>
    <label id="flagged_only" class="hidden"><input id="flagged_only_checkbox" type="checkbox" checked>Flagged only</label>
    <span id="copied" class="hidden">Copied.</span>
  </span>
</div>

<hr id="progress" align="left">
<div id="report" style="margin-top:8px"></div>

<script>
"use strict";

// Results loaded from full_results_jsonp.js.
let globalResults = {};
let globalTestMap = new Map();  // id => test

const TestResultInformation = {
  "CRASH":      { index: 1, text: "Crash", isFailure: true, isSuccess: false },
  "FAIL":       { index: 2, text: "Failure", isFailure: true, isSuccess: false },
  "TEXT":       { index: 3, text: "Failure", isFailure: true, isSuccess: false },
  "IMAGE":      { index: 4, text: "Failure", isFailure: true, isSuccess: false },
  "IMAGE+TEXT": { index: 5, text: "Failure", isFailure: true, isSuccess: false },
  "AUDIO":      { index: 6, text: "Failure", isFailure: true, isSuccess: false },
  "TIMEOUT":    { index: 7, text: "Timeout", isFailure: true, isSuccess: false },
  "LEAK":       { index: 8, text: "LEAK", isFailure: true, isSuccess: false },
  "SKIP":       { index: 9, text: "Skip", isFailure: false, isSuccess: false },
  "PASS":       { index: 10, text: "Pass", isFailure: false, isSuccess: true },
  "NOTRUN":     { index: 11, text: "NOTRUN", isFailure: false, isSuccess: true }
};

// Sorted from worst to best.
const TestResultComparator = function (a, b) {
  if (TestResultInformation[a].index > TestResultInformation[b].index)
    return 1;
  else if (TestResultInformation[a].index == TestResultInformation[b].index)
    return 0;
  else
    return -1;
};

// Traversal traverses all the tests.
// Use Traversal.traverse(filter, action) to perform action on selected tests.
class Traversal {
  constructor(testRoot, textFilter) {
    this.root = testRoot;
    this.reset();
  }

  traverse(filter, action, endAction) {
    console.time("traverse");
    action = action || function() {};
    this._helper(this.root, "", filter, action);
    if (endAction)
      endAction(this);
    console.timeEnd("traverse");
  }

  reset() {
    this.testCount = 0;
    this.filteredCount = 0;
    this.lastDir = "";
    this.html = [];
    return this;
  }

  _helper(node, path, filter, action) {
    if (GUI.isTest(node)) {
      this.testCount++;
      if (filter(node, path)) {
        this.filteredCount++;
        action(node, path, this);
      }
    } else {
      for (let p of node.keys())
        this._helper(node.get(p), path + "/" + p, filter, action);
    }
  }
} // class Traversal

const PathParserGlobals = {
  layout_tests_dir: null,
  chromium_revision: null,
};

class PathParser {
  constructor(path) {
    this.path = path;
    let pathWithoutQuery = path;
    const questionMarkIndex = path.indexOf('?');
    if (questionMarkIndex !== -1) {
      pathWithoutQuery = path.substring(0, questionMarkIndex);
      this.query = path.substring(questionMarkIndex);
    } else {
      this.query = "";
    }
    const lastDirSeparatorIndex = pathWithoutQuery.lastIndexOf('/');
    console.assert(lastDirSeparatorIndex !== -1);  // first char should always be '/'
    this.dir = pathWithoutQuery.substring(1, lastDirSeparatorIndex + 1);
    this.file = pathWithoutQuery.substring(lastDirSeparatorIndex + 1);

    try {
      [, this.basename, this.extension ] = this.file.match(/(.+)\.(.+)/);
      this.resultPrefix = (this.basename + this.query).replace(/[~#%&*{}\:<>?\/|"]/g, "_");
    } catch {
      this.file = "ERROR";
      this.resultPrefix = "ERROR";
    }

    const href = pathWithoutQuery.replace(/^\/virtual\/[^\/]*/, "");  // virtual suites
    this.testHref = this.testBaseHref() + href;
  }

  // If invocations is not empty, we should fetch artifacts from ResultDB.
  static invocations = [];
  static resultDBArtifacts = {};
  static resultNameToArtifactName = {
    '-actual.png': 'actual_image',
    '-expected.png': 'expected_image',
    '-diff.png': 'image_diff',
    '-actual.txt': 'actual_text',
    '-expected.txt': 'expected_text',
    '-diff.txt': 'text_diff',
    '-pretty-diff.html': 'pretty_text_diff',
    '-actual.wav': 'actual_audio',
    '-expected.wav': 'expected_audio',
    '-leak-log.txt': 'leak_log',
    '-crash-log.txt': 'crash_log',
    '-stderr.txt': 'stderr',
    '-command.txt': 'command',
  };
  static numCachedTests = 0;
  static numCachedArtifacts = 0;

  static initGlobals(fullResults) {
    for (let p in PathParserGlobals)
      PathParserGlobals[p] = fullResults[p];
  }

  static setTaskIds(taskIds) {
    for (let taskId of taskIds) {
      // ResultDB invocations need run_ids which end with '1'.
      if (taskId.endsWith('0'))
        taskId = taskId.substring(0, taskId.length - 1) + '1';
      PathParser.invocations.push(
          'invocations/task-chromium-swarm.appspot.com-' + taskId);
    }
    // Pre-load artifacts for tests with unexpected results. If a test that is
    // not included in this pre-loading is expanded, we'll request artifacts
    // for the test in getArtifacts().
    PathParser.queryArtifacts({expectancy: 'VARIANTS_WITH_UNEXPECTED_RESULTS'});
  }

  async resultLink(resultName) {
    if (!PathParser.invocations.length) {
      return this.dir + this.resultPrefix + resultName;
    }
    let artifacts = await this.getArtifacts();
    return artifacts[PathParser.resultNameToArtifactName[resultName]] || 'about:blank';
  }

  resultFilename(resultName) {
    return this.resultPrefix + resultName;
  }

  // TODO(wangxianzhu): This doesn't support ResultDB. Should generate the HTML
  // dynamically instead of using it as an artifact.
  repaintOverlayLink() {
    return this.dir + this.resultPrefix + "-overlay.html?" +
           encodeURIComponent(this.testHref);
  }

  testBaseHref() {
    if (window.localStorage.getItem("testLocationOverride")) {
      // Experimental preference.
      // Use "window.localStorage.setItem("testLocationOverride", "file://path/to/your/web_tests")
      return window.localStorage.getItem("testLocationOverride");
    } else if (PathParserGlobals.layout_tests_dir) {
      return PathParserGlobals.layout_tests_dir;
    } else if (location.toString().indexOf('file://') == 0) {
      // tests were run locally.
      return "../../../third_party/blink/web_tests";
    } else if (PathParserGlobals.chromium_revision) {
      // Existing crrev list is incorrect:  https://crbug.com/750347
      let correctedRevision = PathParserGlobals.chromium_revision.replace("refs/heads/main@{#", "").replace("}", "");
      return "https://crrev.com/" + correctedRevision + "/third_party/blink/web_tests";
    } else {
      return "https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/web_tests";
    }
  }

  getArtifacts() {
    let cache = PathParser.resultDBArtifacts[this.path];
    if (cache) {
      // Refresh if the artifacts will expire in 10 minutes.
      if (cache.artifacts && cache.expiration - new Date().getTime() > 600000) {
        return Promise.resolve(cache.artifacts);
      } else if (cache.promise) {
        // Returns the promise if we are already requesting the artifacts.
        return cache.promise;
      }
    }

    cache = PathParser.resultDBArtifacts[this.path] =
        { artifacts: {}, expiration: 0 };
    let promise = PathParser.queryArtifacts({
      testIdRegexp: `ninja://:[^/]*${this.path}`,
      expectancy: 'ALL',
    }).then(() => { return Promise.resolve(cache.artifacts); });
    // If the promise is not fulfilled immediately, save the promise in the
    // cache entry, so that subsequent requests for the same test can wait for
    // the same promise without creating new requests.
    if (cache.expiration == 0)
      cache.promise = promise;
    return promise;
  }

  static queryArtifacts(testResultPredicate, pageToken) {
    let timeId = "queryArtifacts: " + JSON.stringify(testResultPredicate) +
                 (pageToken || '');
    console.time(timeId);
    console.log('Requesting: ' + timeId);
    return new Promise((resolve, reject) => {
      let xhr = new XMLHttpRequest();
      xhr.open('POST', 'https://results.api.cr.dev/prpc/luci.resultdb.v1.ResultDB/QueryArtifacts');
      xhr.setRequestHeader('Accept', 'application/json');
      xhr.setRequestHeader('Content-Type', 'application/json');
      xhr.onload = () => {
        try {
          if (xhr.status == 200) {
            let response = xhr.responseText;
            if (response.startsWith(")]}'"))
              response = response.substring(5);
            let responseJson = JSON.parse(response);
            if (responseJson.artifacts) {
              for (let artifact of responseJson.artifacts) {
                let match = artifact.name.match(
                    /\/tests\/ninja:%2F%2F:[^%]*(%2F.*)\/results\//);
                if (!match) {
                  continue;
                }
                let testPath = decodeURIComponent(match[1]);
                let cache = PathParser.resultDBArtifacts[testPath];
                if (!cache) {
                  cache = PathParser.resultDBArtifacts[testPath] =
                      { artifacts: {}, expiration: 0 };
                }
                PathParser.numCachedArtifacts++;
                if (cache.expiration == 0) {
                  PathParser.numCachedTests++;
                  console.log('Fetched artifacts for: ' + testPath +
                              ' total tests: ' + PathParser.numCachedTests +
                              ' total artifacts: ' + PathParser.numCachedArtifacts);
                }
                cache.artifacts[artifact.artifactId] = artifact.fetchUrl;
                cache.expiration = new Date(artifact.fetchUrlExpiration).getTime();
                delete cache.promise;
              }
            }
            if (responseJson.nextPageToken) {
              console.log('Requesting next page');
              PathParser.queryArtifacts(testResultPredicate,
                                        responseJson.nextPageToken);
            }
            resolve();
          } else {
            reject(xhr.statusText);
          }
        } catch (e) {
          reject(e);
        } finally {
          console.timeEnd(timeId);
          console.log('Fetched total tests: ' + PathParser.numCachedTests +
                      ' total artifacts: ' + PathParser.numCachedArtifacts);
        }
      };
      xhr.onerror = () => {
        console.timeEnd(timeId);
        reject(xhr.statusText);
      };
      let request = {
        invocations: PathParser.invocations,
        predicate: {testResultPredicate: testResultPredicate},
        pageSize: 1000,
      };
      if (pageToken)
        request.pageToken = pageToken;
      xhr.send(JSON.stringify(request));
    });
  }
} // class PathParser

// Report deals with displaying a single test.
const Report = {

  getDefaultPrinter: () => {
    let val = document.querySelector("#report_format").value;
    window.localStorage.setItem("reportFormat", val);
    switch(val) {
      case "expectation":
        return {print: Report.printExpectation, render: Report.renderResultList};
      case "crashsite":
        return {print: Report.printCrashSite, render: Report.renderGroupCrashSite};
      case "textmismatch":
        return {print: Report.printTextMismatch, render: Report.renderGroupTextMismatch};
      case "rebaseline":
        return {print: Report.printRebaseline, endPrint: Report.endPrintRebaseline, render: Report.renderResultList};
      case "plain":
      default:
        return {print: Report.printPlainTest, render: Report.renderResultList};
    }
  },

  printFlag: (test) => {
    return `<div class="flag ${test.flagged ? "flagged" : ""}"
                title="Hold Meta key to flag/unflag all tests."></div>`;
  },

  printPlainTest: (test, path, traversal) => {
    let pathParser = new PathParser(path);
    let html = `
    <div class='expect' tabindex='0' data-id='${test.expectId}'>
      <div class='details'></div>${Report.printFlag(test)}${pathParser.dir}<a target='test' tabindex='-1' href='${pathParser.testHref}'>${pathParser.file}${pathParser.query}</a>
    </div>`;
    traversal.html.push(html);
  },

  printExpectation: (test, path, traversal) => {
    // TestExpectations file format is documented at:
    // https://chromium.googlesource.com/chromium/src/+/main/docs/testing/web_test_expectations.md

    let pathParser = new PathParser(path);
    // Print directory header if this test's directory is different from the last.
    if (pathParser.dir != traversal.lastDir) {
      traversal.html.push("<br>");
      traversal.html.push("<div class='h-expect'>### " + pathParser.dir + "</div>");
      traversal.lastDir = pathParser.dir;
    }

    let statusMap = new Map(test.expectedMap);
    if (statusMap.has("PASS") && statusMap.size == 1)
       statusMap.delete("PASS");
    test.actualMap.forEach((value, key) => {
      if (key == "TEXT" || key == "IMAGE" || key == "IMAGE+TEXT" || key == "AUDIO")
        key = "FAIL";
      let statusClass = 'actual-result';
      if (!test.expectedMap.has(key))
        statusClass += key == "PASS" ? " unexpected-pass" : " unexpected-failure";
      statusMap.set(key, statusClass);
    });
    let status = test.is_slow_test ? "Slow" : "";
    statusMap.forEach((value, key) => {
      status += ` <span class="${value}">${TestResultInformation[key].text}</span>`;
    });
    let bug = "";
    if (test.bugs && test.bugs.length > 0) {
      for (let b of test.bugs) {
        bug += `<a target='crbug' tabindex='-1' href='https://${b}'>${b}</a> `;
      }
    }
    if (!bug && !Filters.isPass(test.actualMap))
      bug = "<span class='warn'>NEEDBUG</span>";
    let html = `
      <div class='expect' tabindex='0' data-id='${test.expectId}'><div class='details'></div>${Report.printFlag(test)}${bug}
        ${pathParser.dir}<a target='test' tabindex='-1' href='${pathParser.testHref}'>${pathParser.file}${pathParser.query}</a>
        [ ${status} ]
    </div>
    `;
    traversal.html.push(html);
  },

  printWithKey: (test, path, traversal, key_title) => {
    let pathParser = new PathParser(path);
    let key = test[key_title];
    let html = ""
      + `${Report.printFlag(test)}`
      + pathParser.dir
      + "<a target='test' tabindex='-1' href='" + pathParser.testHref + "'>"
      + pathParser.file + pathParser.query + "</a>";
    html = "<div class='expect' tabindex='0' data-id='"+ test.expectId +"'><div class='details'></div>" + html + "</div>";
    traversal.html.push({key: key, html: html});
  },

  printCrashSite: (test, path, traversal) => {
    Report.printWithKey(test, path, traversal, "crash_site");
  },

  printTextMismatch: (test, path, traversal) => {
    Report.printWithKey(test, path, traversal, "text_mismatch");
  },

  printRebaseline: (test, path, traversal) => {
    if (!traversal.html.length) {
      traversal.html.push(`<pre class="warn">
# The following script doesn't properly handle fallbacks. For example, if the
# script changes a baseline that is in the fallback path of a test variant, the
# test variant may be broken. When possible, please use
#   blink_tool.py rebaseline-cl
# instead. You can use --builders=<builder> to rebaseline for one builder, which
# can achieve basically the same result as this script (but with correct
# fallback handling).
</pre>`);
      traversal.html.push(`<pre>
# Before running this script, you should cd into the proper baseline directory.
# For example, if you are rebaselining using the results on mac-rel, you should
# cd into third_party/blink/web_tests/platform/mac.
</pre>`);
    }
    let parser = new PathParser(path);
    let actualNames = [];
    let expectedNames = [];
    switch (test.actualFinal) {
      case "IMAGE+TEXT":
        actualNames.push("-actual.txt");
        expectedNames.push("-expected.txt");
        // fall through IMAGE
      case "IMAGE":
        actualNames.push("-actual.png");
        expectedNames.push("-expected.png");
      break;
      case "TEXT":
        actualNames.push("-actual.txt");
        expectedNames.push("-expected.txt");
      break;
      default:
        return;
    }
    // Change directory if in wrong place
    let dir = parser.dir;
    if (dir != traversal.rebaselineDir) {
      if (traversal.rebaselineDir)
        traversal.html.push('<div>popd >/dev/null;</div>');
      traversal.html.push(`<div>mkdir -p ${dir};</div>`);
      traversal.html.push(`<div>pushd ${dir};</div>`);
      traversal.rebaselineDir = dir;
    }
    for (let i=0; i<actualNames.length; i++) {
      traversal.waitCount = (traversal.waitCount || 0) + 1;
      let index = traversal.html.length;
      traversal.html.push('');
      parser.resultLink(actualNames[i]).then((href) => {
        let url = new URL(href, document.baseURI);
        if (url.href.startsWith('file://')) {
          let actualFile = url.href.substring('file://'.length);
          traversal.html[index] = `<div>cp "${actualFile}" "${parser.basename + expectedNames[i]}";</div>`;
        } else {
          traversal.html[index] =
              `<div>wget "${url.href}" --backups=0 -O "${parser.basename + expectedNames[i]}";</div>`;
        }
        traversal.waitCount--;
      });
    }
    traversal.rebaselinedTests = `${traversal.rebaselinedTests || ''} ${parser.dir}${parser.file}`;
  },

  endPrintRebaseline: (traversal) => {
    if (traversal.rebaselineDir)
      traversal.html.push('<div>popd >/dev/null;</div>');
    if (traversal.rebaselinedTests) {
      traversal.html.push(
          `<div>if ! blink_tool.py optimize-baselines ${traversal.rebaselinedTests}; then</div>`,
          '<div>echo The above command needs third_party/blink/tools in PATH</div>',
          '<div>fi</div>');
    }
  },

  indicateNone: (report) => {
    let pre = document.createElement("div");
    pre.id = "none";
    pre.textContent = "None";
    report.appendChild(pre);
  },

  renderResultList: (html, report) => {
    if (report.childNodes.length === 0 && html.length === 0) {
      Report.indicateNone(report);
      return;
    }

    let pre = document.createElement("div");
    pre.innerHTML = html.join("\n");
    report.appendChild(pre);
  },

  createContainerForGroup: (report, key, keyed_title, null_title) => {
    let container = document.createElement("div");
    container.setAttribute("key", key);
    container.innerHTML = ""
      + "<br><b>"
      + (key !== 'null' ? `### ${keyed_title}: ${key}`
                        : `### ${null_title}`)
      + "</b>";

    // The containers are sorted by key (except the "null" key) alphabetically.
    // The "null" container always appears at the last.
    if (key == "null") {
      report.appendChild(container);
    } else {
      let inserted = false;
      report.childNodes.forEach(sibling => {
        if (inserted)
          return;
        let siblingKey = sibling.getAttribute("key");
        if (siblingKey == "null" || siblingKey > key) {
          report.insertBefore(container, sibling);
          inserted = true;
        }
      });
      if (!inserted)
        report.appendChild(container);
    }
    return container;
  },

  renderGroup: (html, report, keyed_title, null_title) => {
    if (report.childNodes.length === 0 && html.length === 0) {
      Report.indicateNone(report);
      return;
    }

    let renderMap = {};
    html.forEach(result => {
      let key = result.key || "null";
      if (!(key in renderMap)) {
        const number_of_items = html.filter(result => (result.key || "null") === key).length;
        let container =
            report.querySelector(`div[key="${key}"]`) ||
            Report.createContainerForGroup(report, key,
                `${keyed_title} ${number_of_items}`,
                `${null_title} ${number_of_items}`);
        renderMap[key] = {container: container, html: ""};
      }
      renderMap[key].html += result.html;
    });

    for (let key in renderMap)
      renderMap[key].container.insertAdjacentHTML('beforeend', renderMap[key].html);
  },

  renderGroupCrashSite: (html, report) => {
    Report.renderGroup(html, report, "Crash site", "Didn't crash");
  },

  renderGroupTextMismatch: (html, report) => {
    Report.renderGroup(html, report, "Text mismatch failure", "Didn't find text mismatch");
  },

  getTestById: (testId) => {
    return globalTestMap.get(parseInt(testId));
  },

   // Returns toolbar DOM
  getResultToolbars: (test) => {
    let toolbars = [];
    let pathParser = new PathParser(test.expectPath);
    let actual = test.actual;
    if (test.time)
      actual = actual.replace(/ |$/, `(${test.time}s)` + "$&");
    toolbars.push(new PlainHtmlToolbar().createDom(actual));
    for (let result of test.actualMap.keys()) {
      switch(result) {
        case "PASS":
          if (Filters.unexpectedPass(test))
            toolbars.push(new PlainHtmlToolbar().createDom("Expected: " + test.expected));
          if (!test.has_stderr)
            toolbars.push(new PlainHtmlToolbar().createDom("No errors"));
          break;
        case "SKIP":
          toolbars.push(new PlainHtmlToolbar().createDom("Test did not run."));
          break;
        case "CRASH":
          toolbars.push(new SimpleLinkToolbar().createDom(
            pathParser.resultLink("-crash-log.txt"), "Crash log", "crash log"));
          break;
        case "TIMEOUT":
          toolbars.push(new PlainHtmlToolbar().createDom("Test timed out. "));
          if (test.text_mismatch)
            toolbars.push(new TextResultsToolbar().createDom(test));
          break;
        case "TEXT":
          toolbars.push(new TextResultsToolbar().createDom(test));
          break;
        case "IMAGE":
          toolbars.push(new ImageResultsToolbar().createDom(test));
          break;
        case "IMAGE+TEXT":
          toolbars.push(new ImageResultsToolbar().createDom(test));
          toolbars.push(new TextResultsToolbar().createDom(test));
          break;
        case "AUDIO":
          toolbars.push(new AudioResultsToolbar().createDom(test));
          break;
        case "LEAK":
          toolbars.push(new SimpleLinkToolbar().createDom(
            pathParser.resultLink("-leak-log.txt"), "Leak log", "leak log"));
          break;
        default:
          console.error("unexpected actual", test.actual);
      }
    }
    if (test.has_stderr) {
      toolbars.push(new SimpleLinkToolbar().createDom(
            pathParser.resultLink("-stderr.txt"), "standard error", "stderr"));
    }
    if (test.command) {
      toolbars.push(new SimpleLinkToolbar().createDom(
            pathParser.resultLink("-command.txt"), "Command", "command"));
    }
    if (test.shard != undefined && test.shard != "null") {
      toolbars.push(new PlainHtmlToolbar().createDom("Shard: " + test.shard))
    }
    return toolbars;
  },
  getResultsDiv: (test) => {
    let div = document.createElement("div");
    div.classList.add("result-frame");
    div.innerHTML = `
      <ul class="result-menu"></ul>
      <div class="result-output"></div>
    `;
    // Initialize the results.
    let menu = div.querySelector(".result-menu");
    for (let toolbar of Report.getResultToolbars(test))
      menu.appendChild(toolbar);

    if (test.image_diff_stats) {
      let diff_stats = document.createElement("div");
      diff_stats.classList.add("image-diff-stats")
      diff_stats.innerHTML = "<b>Max Channel Difference:</b> " +
        test.image_diff_stats.maxDifference + ", <b>Total Pixels Different:</b> " +
        test.image_diff_stats.totalPixels
      menu.appendChild(diff_stats)
    }

    return div;
  }
}; // Report

// Query generates a report for a given query.
const Query = {
  lastReport: null,
  currentRAF: null,

  resetFilters: function() {
    // Reset all filters.
    for (let el of Array.from(
          document.querySelectorAll("#filters > label"))) {
      el.querySelector('input').checked = true;
    }
  },

  updateFilters: function() {
    for (let el of Array.from(
          document.querySelectorAll("#filters > label"))) {
      let count = this.resultCounts[el.id.replace("_", "+")];
      if (count > 0) {
        el.classList.remove("hidden");
        el.querySelector('span').innerText = count;
      } else {
        el.classList.add("hidden");
        el.querySelector("span").innerText = "";
      }
    }
  },

  filterChanged: function(ev) {
    this.query();
  },

  parseRangeFilter: function(query, key) {
    let range = query.match(key + ":([.0-9]*)-([.0-9]*)");

    if (range) {
      let min = parseFloat(range[1]);
      let max = Number.MAX_VALUE;

      if (range[2] != "") {
        max = parseFloat(range[2]);
      }

      let rem_query = query.substring(0, range.index) + query.substring(range.index +
          range[0].length);
      return [min, max, rem_query];
    } else {
      return [0, Number.MAX_VALUE, query];
    }
  },

  applyFilters: function(queryFilter) {
    var filterMap = new Map();
    for (let el of Array.from(
          document.querySelectorAll("#filters > label"))) {
        if (el.querySelector('input').checked)
          filterMap.set(el.id.replace("_", "+"), true);
    }
    let originalQuery = document.querySelector("#text-filter").value;

    let [timeMin, timeMax, timeQuery] = this.parseRangeFilter(originalQuery, "time");
    let [pixelsMin, pixelsMax, pixelsQuery] = this.parseRangeFilter(timeQuery, "pixels");
    let [channelMin, channelMax, channelQuery] = this.parseRangeFilter(pixelsQuery, "channel_max");

    let searchText = channelQuery.trim();

    let textFilter = test => {
      if (searchText.length > 0) {
        let match = false;
        if (test.expectPath.includes(searchText))
          match = true;
        else if (Array.isArray(test.bugs)) {
          for (let bug of test.bugs) {
            if (bug.includes(searchText)) {
              match = true;
              break;
            }
          }
        }

        if (!match) {
          return false;
        }
      }
      let time = test.time || 0;
      if (time < timeMin || time > timeMax)
        return false;

      if (test.image_diff_stats) {
        let pixels = test.image_diff_stats.totalPixels;
        if (pixels < pixelsMin || pixels > pixelsMax)
          return false;

        let channel_max = test.image_diff_stats.maxDifference;
        if (channel_max < channelMin || channel_max > channelMax)
          return false;
      } else if (pixelsMin > 0 || channelMin > 0) {
          return false;
      }

      return true;
    };
    return test => {
      if (!queryFilter(test) || !textFilter(test))
        return false;
      // Ignore all results except final one, or not?
      let resultKey = test.actualFinal;
      if (resultKey == "TEXT" && test.is_testharness_test)
        resultKey = "HARNESS";
      if (!(resultKey in this.resultCounts))
        this.resultCounts[resultKey] = 0;
      this.resultCounts[resultKey]++;
      return filterMap.has(resultKey);
    };
  },

  query: function(name, queryFilter, reset) {
    queryFilter = queryFilter || this.lastQueryFilter;
    if (reset) {
      this.resetFilters();
      this.lastQueryFilter = queryFilter;
    }
    let composedFilter = this.applyFilters(queryFilter);
    this.generateReport(name, composedFilter);
  },

  // generateReport starts an async job to avoid blocking UI. The previous async
  // job will be canceled if generateReport is called again before the previous
  // one finishes.
  generateReport: function(name, filter, report) {
    console.time("generateReport");
    if (this.currentRAF)
      window.cancelAnimationFrame(this.currentRAF);
    report = report || Report.getDefaultPrinter();
    filter = filter || this.lastReport.filter;
    name = name || this.lastReport.name;

    // Store last report to redisplay.
    this.lastReport = {name: name, filter: filter};
    this.resultCounts = {};

    document.querySelector("#report").innerHTML = "";
    document.querySelector("#report_title").innerHTML = name;
    document.querySelector("#progress").style.width = "1%";
    document.querySelector("#copy_report").disabled = true;
    document.querySelector("#copy_test_names").disabled = true;
        document.querySelector("#report_count").innerText = "";
    let traversal = new Traversal(globalResults.tests);
    let chunkSize = 1000;
    let index = 0;
    let callback = _ => {
      if (traversal.waitCount) {
        this.currentRAF = window.requestAnimationFrame(callback);
        return;
      }
      this.currentRAF = null;
      let html = traversal.html.slice(index, index + chunkSize);
      report.render(html, document.querySelector("#report"));
      index += chunkSize;
      document.querySelector("#progress").style.width = Math.min((index / traversal.html.length * 100), 100) + "%";
      if (index < traversal.html.length) {
        this.currentRAF = window.requestAnimationFrame(callback);
      } else {
        document.querySelector("#report_count").innerText = traversal.filteredCount;
        GUI.updateCopyButtons();
        console.timeEnd("generateReport");
      }
    };
    window.setTimeout( _ => {
      traversal.traverse(filter, report.print, report.endPrint);
      this.updateFilters();
      this.currentRAF = window.requestAnimationFrame(callback);
    }, 0);
  }
}; // Query

// Test filters for queries.
const Filters = {
  containsPass: map => map.has("PASS"),
  isPass: map => map.has("PASS") && map.size == 1,
  unexpectedPass: test => {
    return !Filters.containsPass(test.expectedMap) && Filters.containsPass(test.actualMap);
  },
  regressionFromExpectedMap: (finalResult, expectedMap) => {
    switch (finalResult) {
      case "SKIP":
        return false;
      case "CRASH":
      case "TIMEOUT":
      case "LEAK":
        if (expectedMap && expectedMap.has(finalResult))
          return false;
        break;
      case "TEXT":
      case "IMAGE":
      case "IMAGE+TEXT":
      case "AUDIO":
        if (expectedMap && expectedMap.has("FAIL"))
          return false;
        break;
      default:
        console.error("Unexpected test result", finalResult);
      }
    return true;
  },
  regression: test => {
    if (Filters.containsPass(test.actualMap))
      return false;
    return Filters.regressionFromExpectedMap(test.actualFinal, test.expectedMap);
  },
  notpass: test => test.actualFinal != "PASS",
  actual: tag => {  // Returns comparator for tag.
    return function(test) {
      return test.actualMap.has(tag);
    };
  },
  all: _ => true,
  flaky: test => test.actualMap.size > 1,
  unexpectedFlaky: test => {
    if (!Filters.flaky(test))
      return false;
    let pass_is_expected = Filters.containsPass(test.expectedMap);
    return Array.from(test.actualMap.keys()).some(k => {
      return (k == "PASS" && !pass_is_expected) ||
             Filters.regressionFromExpectedMap(k, test.expectedMap);
    });
  },
  flagged: test => test.flagged,
  flagFailure: test => { // Tests that are failing, but expected to pass in base.
    if (Filters.containsPass(test.actualMap))
      return false;
    let baseMap = test.flagMap ? test.baseMap : test.expectedMap;
    return Filters.regressionFromExpectedMap(test.actualFinal, baseMap);
  },
  flagPass: test => {
    return test.baseMap && (Filters.containsPass(test.actualMap) && !Filters.containsPass(test.baseMap));
  },
  flagUnexpectedPass: test => {
    return test.flagMap && !Filters.containsPass(test.flagMap) && Filters.containsPass(test.actualMap);
  },
}; // Filters

// Event handling, initialization.
const GUI = {
  initPage: function(results) {
    results.tests = GUI.convertToMap(results.tests);
    globalResults = results;
    if (window.localStorage.getItem("reportFormat")) {
      document.querySelector("#report_format").value = window.localStorage.getItem("reportFormat");
    }
    GUI.optimizeResults(globalResults);
    GUI.printSummary(globalResults);
    GUI.initEvents();
    PathParser.initGlobals(results);
    // Show regressions on startup.
    document.querySelector("#button_regressions").click();
  },

  hasBaseExpectations : false,

  isTest: function(o) {
    return "actual" in o;
  },

  convertToMap: function(o) {
    if (GUI.isTest(o))
      return o;
    else {
      let map = new Map();
      var keys = Object.keys(o).sort((a, b) => {
        let a_isTest = GUI.isTest(o[a]);
        if (a_isTest == GUI.isTest(o[b]))
          return a < b ? -1 : +(a > b);
        return a_isTest ? -1 : 1;
      });
      for (let p of keys)
        map.set(p, GUI.convertToMap(o[p]));
      return map;
    }
  },

  translateArtifactsIntoResult: function(artifacts, result, test){
    if (artifacts.hasOwnProperty('command'))
      test.command = artifacts.command;

    if (result != 'FAIL')
      return result;
    // Check detailed failure type based on artifacts.
    let is_image = artifacts.hasOwnProperty('actual_image') ||
                   artifacts.hasOwnProperty('expected_image');
    let is_text = artifacts.hasOwnProperty('actual_text') ||
                  artifacts.hasOwnProperty('expected_text');
    let is_audio = artifacts.hasOwnProperty('actual_audio') ||
                   artifacts.hasOwnProperty('expected_audio');
    let is_leak = artifacts.hasOwnProperty('leak_log');

    if (artifacts.hasOwnProperty('reference_file_mismatch'))
      test.reference = artifacts.reference_file_mismatch;

    if (is_image && is_text)
      return 'IMAGE+TEXT';
    if (is_image)
      return 'IMAGE';
    if (is_text)
      return 'TEXT';
    if (is_audio)
      return 'AUDIO';
    if (is_leak)
      return 'LEAK';
    console.error("Unknown failure type");
    return result;
  },

  deriveWebTestResults: function(test){
    let actual_results = [];
    for(let [iteration, result] of test.actual.split(' ').entries()){
      if(test.artifacts != null){
        let perIterationArtifact = Object();
        let lastArtifact = Object();
        for(let [name, paths] of Object.entries(test.artifacts)){
          for(let path of paths){
            const is_retry_artifact_path = (path.startsWith('layout-test-results/retry_') ||
                                            path.startsWith('layout-test-results\\retry_'));
            if(iteration == 0 && !is_retry_artifact_path ||
               iteration > 0 && is_retry_artifact_path){
              if(!perIterationArtifact.hasOwnProperty(name)){
                perIterationArtifact[name] = [];
              }
              perIterationArtifact[name].push(path);
            } else {
              lastArtifact[name] = path;
            }
          }
        }
        for (let [name, artifact] of Object.entries(lastArtifact)) {
          if (!perIterationArtifact.hasOwnProperty(name))
              perIterationArtifact[name] = [artifact];
        }
        actual_results.push(GUI.translateArtifactsIntoResult(
            perIterationArtifact, result, test));
      }else{
        actual_results.push(result);
      }
    }
    return actual_results;
  },

  optimizeResults: function(fullResults) {
    // Optimizes fullResults for querying.
    let t = new Traversal(fullResults.tests);
    // To all tests add:
    // - test.expectId, a unique id
    // - test.expectPath, full path to test
    // - test.actualMap, map of actual results
    // - test.actualFinal, last result
    // - test.expectedMap, maps of all expected results
    // - test.baseMap, map of base expected results. Can be undefined.
    // - test.flagMap, map of flag expected results. Can be undefined.
    // For all crashing tests without crash_site, set crash_site to "Can't identify".
    let nextId = 1;
    let baseCount = 0;
    t.traverse(
      test => true,
      (test, path) => {
        let web_test_results = GUI.deriveWebTestResults(test);
        test.expectId = nextId++;
        globalTestMap.set(test.expectId, test);
        test.expectPath = path;
        test.actualMap = new Map();
        test.actual = web_test_results.join(' ');
        for (let result of web_test_results) {
          test.actualFinal = result;  // last result count as definite.
          test.actualMap.set(result, true);
        }
        test.expectedMap = new Map();
        for (let result of test.expected.split(" ")) {
          test.expectedMap.set(result, true);
        }
        if (test.actualMap.has("CRASH") && !test["crash_site"]) {
          test["crash_site"] = "Can't identify";
        }
        if ("base_expectations" in test) {
          GUI.hasBaseExpectations = true;
          baseCount++;
          test.baseMap = new Map();
          test.base_expectations.forEach( result => test.baseMap.set(result, true));
        }
        if ("flag_expectations" in test) {
          test.flagMap = new Map();
          test.flag_expectations.forEach( result => test.flagMap.set(result, true));
        }
      }
    );
  },

  nextExpectation: function(expectation) {
    let nextSiblingWithKeyedParentSkip = function(el) {
      let sibling = el.nextElementSibling;
      if (sibling == null && el.parentNode.parentNode.id == "report") {
        let nextContainer = el.parentNode.nextElementSibling;
        while (nextContainer != null && nextContainer.firstElementChild == null)
          nextContainer = nextContainer.nextElementSibling;
        if (nextContainer)
          sibling = nextContainer.firstElementChild;
      }
      return sibling;
    };
    if (expectation == null)
      return null;
    let sibling = nextSiblingWithKeyedParentSkip(expectation);
    while (sibling) {
      if (sibling.classList.contains("expect"))
        return sibling;
      else
        sibling = nextSiblingWithKeyedParentSkip(sibling);
    }
  },

  previousExpectation: function(expectation) {
    let previousSiblingWithKeyedParentSkip = function(el) {
      let sibling = el.previousElementSibling;
      if (sibling == null && el.parentNode.parentNode.id == "report") {
        let previousContainer = el.parentNode.previousElementSibling;
        while (previousContainer != null && previousContainer.firstElementChild == null)
          previousContainer = previousContainer.previousElementSibling;
        if (previousContainer)
          sibling = previousContainer.lastElementChild;
      }
      return sibling;
    };
    if (expectation == null)
      return null;
    let sibling = previousSiblingWithKeyedParentSkip(expectation);
    while (sibling) {
      if (sibling.classList.contains("expect"))
        return sibling;
      else
        sibling = previousSiblingWithKeyedParentSkip(sibling);
    }
  },

  showNextExpectation: function(backward) {
    let nextExpectation;
    let activeExpectation = GUI.activeExpectation();
    let openExpectation = GUI.openExpectation();
    if (openExpectation)
      GUI.hideResults(openExpectation);
    if (openExpectation && openExpectation == activeExpectation) {
      nextExpectation = backward ?
        GUI.previousExpectation(openExpectation) :
        GUI.nextExpectation(openExpectation);
    } else {
      if (activeExpectation)
        nextExpectation = activeExpectation;
      else
        nextExpectation = document.querySelector(".expect");
    }
    if (nextExpectation) {
      nextExpectation.focus();
      GUI.showResults(nextExpectation);
      return true;
    }
  },

  openExpectation: function() {
    let openDetails = document.querySelector(".details.open");
    return openDetails && GUI.getExpectation(openDetails);
  },

  activeExpectation: function() {
    return GUI.getExpectation(document.activeElement) || GUI.openExpectation();
  },

  initEvents: function() {
    document.querySelector("#report").addEventListener("mousedown",
      (ev) => { if (GUI.isFlag(ev.target)) ev.preventDefault() }
    );
    document.querySelector("#report").addEventListener("click", function(ev) {
      let expectation = GUI.getExpectation(ev.target);
      if (ev.target.nodeName == "A" || ev.target.nodeName == "INPUT" ||
          GUI.closest(ev.target, "result-frame")) {
        ; // Clicks in anchor, input or result-frame should perform default action
      } else if (GUI.isFlag(ev.target)) {
        GUI.toggleFlag(ev.target, ev);
      } else if (expectation && window.getSelection().type != "Range") {
        GUI.toggleResults(expectation, ev);
        ev.preventDefault();
        ev.stopPropagation();
      }
    });
    document.addEventListener("keydown", ev => {
      {
        if (ev.target.nodeName == "INPUT")
          return;
        switch(ev.key) {
          case "Escape": {
              // Close/hide divs.
              for (let el of Array.from(document.querySelectorAll(".close-on-esc")))
                el.remove();
              for (let el of Array.from(document.querySelectorAll(".hide-on-esc")))
                el.classList.add("hidden");
              if (document.activeElement && document.activeElement.classList.contains("expect"))
                GUI.hideResults(document.activeElement);
              document.getSelection().removeAllRanges();
            }
            break;
          case " ": // Scroll to next expectation.
            if (GUI.showNextExpectation(ev.shiftKey))
              ev.preventDefault();
            break;
          case "j":
          case "J": {
            let current = GUI.activeExpectation();
            let nextExpectation = current ? GUI.nextExpectation(current) : document.querySelector(".expect");
            if (nextExpectation)
              nextExpectation.focus();
            }
            break;
          case "k":
          case "K": {
            let current = GUI.activeExpectation();
            let nextExpectation = current ? GUI.previousExpectation(current) : document.querySelector(".expect");
            if (nextExpectation)
              nextExpectation.focus();
            }
            break;
          case "Enter":
            let expectation = GUI.getExpectation(ev.target);
            if (expectation)
              GUI.toggleResults(expectation, ev);
            break;
          case "a":
          case "A":
            if (ev.ctrlKey) {
              GUI.selectText(document.querySelector("#report"));
              ev.preventDefault();
            }
            break;
          case "f":
          case "F": {
              let expectation = GUI.getExpectation(ev.target);
              if (expectation)
                GUI.toggleFlag(expectation.querySelector(".flag"), ev);
            }
            break;
          case "?":
          case "/": // Blinks currect image error rect
            if (document.querySelector(".image-viewer"))
              document.querySelector(".image-viewer").controller.blinkDiffRect();
            break;
          case "p": {
            let expectation = GUI.getExpectation(ev.target);
            if (expectation)
              console.log(Report.getTestById(expectation.getAttribute("data-id")));
            break;
          }
        }
      }
    });
    for (let checkbox of Array.from(document.querySelectorAll("#filters input[type=checkbox]"))) {
      checkbox.addEventListener("change", ev => Query.filterChanged(ev));
    }
  },

  selectText: function(el) {
    let range = document.createRange();
    range.setStart(el, 0);
    range.setEnd(el, el.childNodes.length);
    let selection = document.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);
  },

  copyText: function(el) {
    GUI.selectText(el);
    document.execCommand("Copy");
    document.getSelection().removeAllRanges();
    document.querySelector("#copied").classList.remove("hidden");
    if (GUI.endCopyTextTimer)
      window.clearTimeout(GUI.endCopyTextTimer);
    GUI.endCopyTextTimer = window.setTimeout(_ => {
      document.querySelector("#copied").classList.add("hidden");
      GUI.endCopyTextTimer = null;
    }, 2000);
  },

  copyResult: function(asSingleLine) {
    console.time("copyResult");
    let flaggedOnly = document.querySelector(".flagged") &&
                      document.querySelector("#flagged_only_checkbox").checked;
    if (!flaggedOnly && !asSingleLine) {
      GUI.copyText(document.querySelector("#report"));
      return;
    }

    let printer = Report.getDefaultPrinter();
    let singleLine = "";
    let traversal = new Traversal(globalResults.tests);
    traversal.traverse(Query.lastReport.filter, (test, path) => {
      if (flaggedOnly && !test.flagged)
        return;
      if (asSingleLine) {
        let pathParser = new PathParser(path);
        singleLine += pathParser.dir + pathParser.file + " ";
      } else {
        printer.print(test, path, traversal);
      }
    }, printer.endPrint);
    let div = document.createElement("div");
    div.setAttribute("style", "overflow: hidden; color: white; height: 1px");
    if (asSingleLine)
      div.textContent = singleLine;
    else
      printer.render(traversal.html, div);
    document.body.appendChild(div);
    GUI.copyText(div);
    document.body.removeChild(div);
    console.timeEnd("copyResult");
  },

  updateCopyButtons: function() {
    let noResult = document.querySelector("#none");
    document.querySelector("#copy_report").disabled = noResult;
    document.querySelector("#copy_test_names").disabled = noResult;

    let flagged_only = document.querySelector("#flagged_only");
    if (document.querySelector(".flagged"))
      flagged_only.classList.remove("hidden");
    else
      flagged_only.classList.add("hidden");
  },

  printSummary: function (fullResults) {
    document.querySelector("#builder_name").innerText = fullResults.builder_name || document.lastModified;
    document.querySelector("#summary_passed").innerText = fullResults.num_passes;
    document.querySelector("#summary_regressions").innerText = fullResults.num_regressions;
    let failures = fullResults["num_failures_by_type"];
    var totalFailures = 0;

    let resultsText = "";
    for (let p in failures) {
      if (failures[p])
        resultsText += p.toLowerCase() + ": " + failures[p] + " ";
    }
    document.querySelector("#summary_details").innerText = resultsText;

    // Initialize query counts.
    let counts = {
      "count_unexpected_pass": 0,
      "count_regressions": 0,
      "count_testexpectations": 0,
      "count_flaky": 0,
      "count_unexpected_flaky": 0,
      "count_all": 0,
      "count_flag_failure": 0,
      "count_flag_pass" : 0,
      "count_flag_unexpected_pass" : 0,
    };
    var t = new Traversal(fullResults.tests);
    t.traverse( test => {
      counts.count_all++;
      if (Filters.unexpectedPass(test))
        counts.count_unexpected_pass++;
      if (Filters.regression(test))
        counts.count_regressions++;
      if (Filters.notpass(test))
        counts.count_testexpectations++;
      if (Filters.flaky(test))
        counts.count_flaky++;
      if (Filters.unexpectedFlaky(test))
        counts.count_unexpected_flaky++;
      if (Filters.flagFailure(test))
        counts.count_flag_failure++;
      if (Filters.flagPass(test))
        counts.count_flag_pass++;
      if (Filters.flagUnexpectedPass(test))
        counts.count_flag_unexpected_pass++;
    });
    console.assert(
        counts.count_regressions == fullResults.num_regressions,
        "Numbers of regressions mismatch: in fullResult:" + fullResults.num_regressions +
        " filtered:" + counts.count_regressions);
    for (let p in counts)
      document.querySelector("#" + p).innerText = counts[p];
    if (counts.count_regressions > 0)
      document.querySelector("#count_regressions").parentElement.classList.add("bad");
    if (counts.count_unexpected_flaky > 0)
      document.querySelector("#count_unexpected_flaky").parentElement.classList.add("bad");
    if (counts.count_unexpected_pass > 0)
      document.querySelector("#count_unexpected_pass").parentElement.classList.add("good");

    document.querySelector("#summary_total").innerText = counts.count_all;
    if (GUI.hasBaseExpectations || fullResults.flag_name) {
      document.querySelector("#flag-toolbar").classList.remove("hidden");
      let flagName = fullResults.flag_name || "";
      flagName = flagName.replace('/', '')
        .replace('enable-blink-features', '').replace('=', '');
      Array.from(document.querySelectorAll(".flag_name")).forEach( el => {
        el.innerText = flagName;
      });
    } else {
      document.querySelector("#flag-toolbar").classList.add("hidden");
    }
  },

  getExpectation: function(el) {
    let result = GUI.closest(el, "expect");
    if (result)
      return result;
    result = GUI.closest(el, "result-frame");
    return result ? result.previousElementSibling : null;
  },

  isFlag: function(el) {
    return el.classList.contains("flag");
  },

  toggleVisibility: function(id) {
    document.querySelector("#" + id).classList.toggle("hidden");
  },

  toggleFlag: function(el, ev) {
    let expectation = GUI.getExpectation(el);
    let test = Report.getTestById(expectation.getAttribute("data-id"));
    if (!test)
      throw "could not find test by id";
    el.classList.toggle("flagged");
    if (el.classList.contains("flagged"))
      test.flagged = true;
    else
      test.flagged = false;
    if (ev.metaKey) { // apply to all
      for (let expectation of Array.from(document.querySelectorAll(".expect"))) {
        let newTest = Report.getTestById(expectation.getAttribute("data-id"));
        let toggleEl = expectation.querySelector(".flag");
        if (test.flagged) {
          toggleEl.classList.add("flagged");
          newTest.flagged = true;
        } else {
          toggleEl.classList.remove("flagged");
          newTest.flagged = false;
        }
      }
    }
    GUI.updateCopyButtons();
  },

  toggleResults: function(expectation, event) {
    let applyToAll = event && event.metaKey;
    let closeOthers = !applyToAll && event && !event.shiftKey;
    let details = expectation.querySelector(".details");
    let isOpen = details.classList.contains("open");
    if (applyToAll) {
      let allExpectations = Array.from(document.querySelectorAll(".expect"));
      if (allExpectations.length > 100) {
        console.error("Too many details to be shown at once");
      } else {
        for (let e of allExpectations)
          if (e != expectation)
            isOpen ? GUI.hideResults(e) : GUI.showResults(e, true);
      }
    }
    if (closeOthers) {
      for (let el of Array.from(document.querySelectorAll(".details.open")))
        GUI.hideResults(el.parentNode);
    }
    if (isOpen) {
      GUI.hideResults(expectation);
      expectation.focus();
    }
    else {
      GUI.showResults(expectation);
    }
  },

  getResultViewer: function(toolbar) {
    let output = GUI.closest(toolbar, "result-frame").querySelector(".result-output");
    if (output && output.children.length > 0)
      return output.children[0];
  },

  setResultViewer: function(toolbar, viewer) {
    let output = GUI.closest(toolbar, "result-frame").querySelector(".result-output");
    output.innerHTML = "";
    output.appendChild(viewer);
  },

  closest: function (el, className) {
    while (el && el.classList) {
      if (el.classList.contains(className))
        return el;
      else
        el = el.parentNode;
    }
  },

  showResults: function(expectation, doNotScroll) {
    let details = expectation.querySelector(".details");
    if (details.classList.contains("open"))
      return;
    details.classList.add("open");
    let testId = parseInt(expectation.getAttribute("data-id"));
    let test = Report.getTestById(testId);
    if (!test)
      console.error("could not find test by id");
    let results = Report.getResultsDiv(test);
    results.classList.add("results");
    expectation.parentNode.insertBefore(results, expectation.nextSibling);
    for (let toolbar of Array.from(results.querySelectorAll(".tx-toolbar"))) {
      if (toolbar.showDefault) {
        toolbar.showDefault();
        break;
      }
    }
    if (doNotScroll) {
      return;
    }
    // Scroll result into view, leaving space for image zoom, and
    // test title on top.
    let zoomHeight = window.innerWidth / 3 + 10;
    let resultHeight = Math.min(window.innerHeight - 80, 630) + 34;
    let overflow = zoomHeight + resultHeight + expectation.offsetHeight - window.innerHeight;
    if (overflow > 0)
      zoomHeight = Math.max(0, zoomHeight - overflow);
    window.scrollTo(0, expectation.offsetTop - zoomHeight);
  },

  hideResults: function(expectation) {
    let details = expectation.querySelector(".details");
    if (!details.classList.contains("open"))
      return;
    expectation.querySelector(".details").classList.remove("open");
    expectation.nextSibling.remove();
  },

  showTimingStats: function() {
    let table = document.querySelector("#timing-stats-table");
    if (!table.firstChild) {
      let counts = [];
      let virtual_counts = {};
      let times = [];
      let virtual_times = {};
      let timeout_counts = [];
      let virtual_timeout_counts = {};
      let total_count = 0;
      let total_count_timeouts = 0;
      let max = 0;
      let total_time_all = 0;
      let total_time_timeouts = 0;
      new Traversal(globalResults.tests).traverse(
        test => test.actualFinal != "SKIP",
        (test, path) => {
          let time = test.time || 0;
          let int_time = Math.floor(time);
          counts[int_time] = (counts[int_time] || 0) + 1;
          times[int_time] = (times[int_time] || 0) + time;
          total_time_all += time;
          // result.time is measured during the first run, so check the first
          // TIMEOUT here.
          let is_timeout = test.actual.startsWith('TIMEOUT');
          if (is_timeout) {
            timeout_counts[int_time] = (timeout_counts[int_time] || 0) + 1;
            total_time_timeouts += time;
            total_count_timeouts++;
          }
          total_count++;
          max = Math.max(max, int_time + 1);

          if (path.startsWith('/virtual/')) {
            let suite = path.split('/', 3)[2];
            virtual_counts[suite] = (virtual_counts[suite] || 0) + 1;
            virtual_times[suite] = (virtual_times[suite] || 0) + time;
            virtual_timeout_counts[suite] =
                (virtual_timeout_counts[suite] || 0) + (is_timeout ? 1 : 0);
          }
        }
      );
      let i = 0;
      let html = `<tr>
                    <th>Range</th>
                    <th colspan="2">Count/Percent<img class="stat-bar-count"></th>
                    <th>Timeouts</th>
                    <th colspan="2">Time/Percent<img class="stat-bar-time"></th>
                    <th style='width: 60%'></th>
                  </tr>`;
      for (let end of [1, 2, 3, 4, 6, 8, 10, 15, 20, 30, 45, 60, 90, 9999]) {
        let begin = i;
        end = Math.min(end, max);
        let count = 0, timeout_count = 0, time = 0;
        for (; i < end; i++) {
          count += (counts[i] || 0);
          timeout_count += (timeout_counts[i] || 0);
          time += (times[i] || 0);
        }
        let count_percent = (count / total_count * 100).toFixed(2);
        let time_percent = (time / total_time_all * 100).toFixed(2);
        html += `<tr>
                   <td>${begin}-${end}s</td>
                   <td>${count}</td><td>${count_percent}%</td>
                   <td>${timeout_count}</td>
                   <td>${Math.round(time)}s</td><td>${time_percent}%</td>
                   <td>
                     <div class="stat-bar-count" style="width: ${count_percent}%"></div>
                     <div class="stat-bar-time" style="width: ${time_percent}%"></div>
                   </td>
                 </tr>`;
        if (end == max)
          break;
      }
      html += `<tr>
                 <th>Total</th>
                 <th>${total_count}</th><th>100%</th>
                 <th>${total_count_timeouts}</th>
                 <th>${Math.round(total_time_all)}s</th><th>100%</th>
               </tr>
               <tr>
                 <td colspan="4">Timeouts:</td>
                 <td>${Math.round(total_time_timeouts)}s</td>
               </tr>`;
      table.innerHTML = html;

      // Generate timing stats for virtual suites.
      html = `<tr>
                <th>Suite</th>
                <th colspan="2">Count/Percent<img class="stat-bar-count"></th>
                <th>Timeouts</th>
                <th colspan="2">Time/Percent<img class="stat-bar-time"></th>
                <th style="width: 40%"></th>
              </tr>`;
      let virtual_array = [];
      for (let suite in virtual_counts) {
        virtual_array.push({
          suite: suite,
          count: virtual_counts[suite],
          time: virtual_times[suite],
          timeout_count: virtual_timeout_counts[suite],
        });
      }
      virtual_array.sort((a, b) => { return b.time - a.time; });
      for (let v of virtual_array) {
        let count_percent = (v.count / total_count * 100).toFixed(2);
        let time_percent = (v.time / total_time_all * 100).toFixed(2);
        html += `<tr>
                   <td>${v.suite}</td>
                   <td>${v.count}</td><td>${count_percent}%</td>
                   <td>${v.timeout_count}</td>
                   <td>${Math.round(v.time)}s</td><td>${time_percent}%</td>
                   <td>
                     <div class="stat-bar-count" style="width: ${count_percent * 3}%"></div>
                     <div class="stat-bar-time" style="width: ${time_percent * 3}%"></div>
                   </td>
                 </tr>`;
      }
      document.querySelector('#timing-stats-virtuals').innerHTML = html;
    }
    GUI.toggleVisibility('timing-stats');
  },
}; // GUI

</script>

<script>
// test-expectation components
// These components are in a separate script tag.
// They are independent of the rest of the page so they can be reused
// in different pages in the future.
//
// Current components include toolbars, and viewers.
// Toolbars control viewers.
// Viewers present different views into test results.
class TXToolbar {
  importStyle() {
    if (document.querySelector("#TXToolbarCSS"))
      return;
    let style = document.createElement("style");
    style.setAttribute("type", "text/css");
    style.setAttribute("id", "TXToolbarCSS");
    style.innerText =
    `.tx-toolbar a[data-selected] {
      background-color: #DDD;
      text-decoration-color: aquamarine;
    }
    .tx-toolbar a {
      margin: 0 2px;
      padding: 0 2px;
    }
    `;
    document.head.appendChild(style);
  }

  getViewer() {
    let viewer = GUI.getResultViewer(this.toolbar);
    return (viewer && viewer.owner == this) ? viewer : null;
  }

  defaultExtendSelection(anchor) {
    return false;
  }

  createAnchor(className, linkOrPromise, title, innerHTML, download) {
    let anchor = document.createElement('a');
    anchor.className = className;
    anchor.title = title;
    anchor.innerHTML = innerHTML;
    if (download)
      anchor.download = download;
    let updateViewer = (() => { this.updateViewer(anchor); }).bind(this);
    Promise.resolve(linkOrPromise).then((href) => {
      anchor.href = href;
      if (anchor.hasAttribute("data-selected")) {
        setTimeout(updateViewer, 0);
      }
    });
    return anchor;
  }

  selectAnchor(target, extendSelection) {
    let toggle = false;
    if (extendSelection === undefined) {
      toggle = true;
      extendSelection = this.defaultExtendSelection(target);
    }
    for (let anchor of Array.from(this.toolbar.querySelectorAll("a"))) {
      if (anchor == target) {
        if (toggle) {
          if (anchor.hasAttribute("data-selected"))
            anchor.removeAttribute("data-selected");
          else
            anchor.setAttribute("data-selected", "");
        } else {
          anchor.setAttribute("data-selected", "");
        }
      } else if (!extendSelection && !this.defaultExtendSelection(anchor)) {
        anchor.removeAttribute("data-selected");
      }
    }
    this.updateViewer(target);
  }

  getAnchorInfo(query) {
    let anchorInfo = [];
    for (let anchor of this.toolbar.querySelectorAll(query)) {
      // One of the anchors' href is not ready yet.
      if (!anchor.href)
        return undefined;
      anchorInfo.push({src: anchor.href, title: anchor.title});
    }
    return anchorInfo;
  }
} // class TXToolbar

class ImageResultsToolbar extends TXToolbar {
  constructor() {
    super();
    this.boundViewOptionsChangeHandler = this.viewOptionsChangeHandler.bind(this);
  }

  createDom(test) {
    this.importStyle();
    let pathParser = new PathParser(test.expectPath);
    this.toolbar = document.createElement("li");
    this.toolbar.showDefault = _ => this.viewOptionsChangeHandler({target: this.toolbar.querySelector(".view-options")});
    this.toolbar.classList.add("image-toolbar");
    this.toolbar.classList.add("tx-toolbar");

    this.toolbar.appendChild(document.createTextNode("image: "));
    let actualLinkPromise = pathParser.resultLink("-actual.png");
    this.toolbar.appendChild(this.createAnchor(
        "toggle", actualLinkPromise, "Actual result", "actual"));
    this.toolbar.appendChild(this.createAnchor(
        "", actualLinkPromise, "Download new expectation file",
        '<img class="download-button" alt="Download new expectation file" />'));
    this.toolbar.lastChild.download = pathParser.resultFilename("-expected.png");
    this.toolbar.appendChild(this.createAnchor(
        "toggle", pathParser.resultLink("-expected.png"), "Expected result", "expected"));
    if (test.reference) {
      let span = document.createElement("span");
      span.style = "margin-left: -2px; margin-right: 2px";
      // TODO(wangxianzhu): Make this link work for bot results.
      span.appendChild(this.createAnchor("", `../${test.reference}`, "Reference", "ref"));
      span.lastChild.target = "reference";
      this.toolbar.appendChild(span);
    }
    this.toolbar.appendChild(this.createAnchor(
        "toggle", pathParser.resultLink("-diff.png"), "Difference", "diff"));
    this.toolbar.lastChild.className = "toggle";

    this.toolbar.insertAdjacentHTML("beforeend", `
      <label><select class="view-options">
        <option value="single">Single view</option>
        <option value="animated">Animated view</option>
        <option value="multiple">Side by side view</option>
      </select></label>
    `);

    this.toolbar.addEventListener("click", ev => {
      if (ev.target.tagName == "A" && ev.target.className == "toggle") {
        this.selectAnchor(ev.target);
        ev.preventDefault();
        if (this.animationIntervalId) {
          // Suspend animation to show the clicked view for one second.
          this.animationPaused = true;
          window.setTimeout(_ => { this.animationPaused = false }, 1000);
        }
      }
    });

    let viewOptions = this.toolbar.querySelector(".view-options");
    viewOptions.addEventListener("change", this.boundViewOptionsChangeHandler);
    if (window.localStorage.getItem("ImageToolbarView"))
      viewOptions.value = window.localStorage.getItem("ImageToolbarView");
    else
      viewOptions.value = "animated";
    return this.toolbar;
  }

  viewOptionsChangeHandler(ev) {
    let viewOptions = ev.target;
    try {
      window.localStorage.setItem("ImageToolbarView", viewOptions.value);
    } catch(ex) {}

    switch(viewOptions.value) {
    case "animated": {
      let selectedAnchors = Array.from(this.toolbar.querySelectorAll("a[data-selected]"));
      if (selectedAnchors.length == 0)
        this.selectAnchor(this.toolbar.querySelector("a"));
      this.setAnimation(true);
      }
      break;
    case "single": {
      this.setAnimation(false);
      // Make sure only one is selected.
      let selectedAnchors = Array.from(this.toolbar.querySelectorAll("a[data-selected]"));
      if (selectedAnchors.length == 0)
        this.selectAnchor(this.toolbar.querySelector("a"));
      else
        this.selectAnchor(selectedAnchors[0]);
      }
      break;
    case "multiple": {
      this.setAnimation(false);
      for (let anchor of Array.from(this.toolbar.querySelectorAll("a.toggle")))
        this.selectAnchor(anchor, true);
      }
      break;
    default:
      console.error("unknown view option");
    }
  }

  defaultExtendSelection(anchor) {
    return this.toolbar.querySelector(".view-options").value == "multiple";
  }

  updateViewer(element) {
    // Find currently selected anchor.
    let selectedAnchors = Array.from(this.toolbar.querySelectorAll("a[data-selected]"));
    if (selectedAnchors.length == 0) {
      selectedAnchors.push(this.toolbar.querySelector("a"));
      selectedAnchors[0].setAttribute("data-selected", "");
    }
    let viewer = this.getViewer();
    if (!viewer) {
      let imgInfo = this.getAnchorInfo("a.toggle");
      if (!imgInfo)
        return;
      viewer = (new ImageResultsViewer()).createDom(imgInfo, this);
      this.setAnimation(this.toolbar.querySelector(".view-options").value == "animated");
      GUI.setResultViewer(element, viewer);
    }
    viewer.showImages(selectedAnchors.map(anchor => anchor.href));
  }

  setAnimation(animate) {
    if (animate) {
      if (!this.animationIntervalId)
        this.animationIntervalId = window.setInterval(_ => {
          if (!this.getViewer())
            return;
          if (this.animationPaused)
            return;
          // Find next anchor.
          let allAnchors = Array.from(this.toolbar.querySelectorAll("a.toggle"));
          let nextAnchor = allAnchors[0];
          for (let i = 0; i < allAnchors.length; i++) {
            if (allAnchors[i].hasAttribute("data-selected")) {
              nextAnchor = allAnchors[(i + 1) % (allAnchors.length - 1)];
              break;
            }
          }
          this.selectAnchor(nextAnchor, false);
        }, 400);
    } else {
      this.animationPaused = false;
      if (this.animationIntervalId) {
        window.clearInterval(this.animationIntervalId);
        this.animationIntervalId = null;
      }
    }
  }
} // class ImageResultsToolbar

class ImageResultsViewer {
  constructor() {
    this.boundEventMoveHandler = this.eventMoveHandler.bind(this);
    this.boundTileScrollHandler = this.tileScrollHandler.bind(this);
    this.boundWheelHandler = this.tileWheelHandler.bind(this);
  }

  importStyle() {
    if (document.querySelector("#ImageViewerCSS"))
      return;
    let style = document.createElement("style");
    style.setAttribute("type", "text/css");
    style.setAttribute("id", "ImageViewerCSS");
    style.innerText =
    ` .image-viewer {
      position: relative;
      display: flex;
    }
    .image-viewer-tile {
      flex-shrink: 1;
      border: 1px solid #EEE;
      overflow: auto;
      max-height: calc(100vh - 80px);
      margin-right: 8px;
      min-height: 605px;
    }
    .image-viewer-highlight {
      position: absolute;
      background-color: red;
      opacity: 0.5;
      box-shadow: 0px 0px 8px 2px rgba(255,0,0,1);
    }
    .image-viewer-highlight.animate {
      animation-name: highlight-animation;
      animation-duration: 0.15s;
      animation-timing-function: ease-out;
      animation-delay: 0s;
      animation-direction: alternate;
      animation-iteration-count: 2;
    }
    .image-viewer-highlight.animate-long {
      animation-iteration-count: 4;
    }
    @keyframes highlight-animation {
      0% {
        display: block;
        opacity: 0;
        transform: scale(1.0);
      }
      100% {
        display: block;
        opacity: 0.5;
        transform: scale(1.1);
      }
    }`;
    document.head.appendChild(style);
  }

  /* ImageResultsViewer dom
    <div class="image-viewer">
      <div class="image-viewer-tile">
        <img src="...-actual" title="Actual result">
      </div>
      <div class="image-viewer-tile">
        <img src="...-expected" title="Expected result">
      </div>
      <div class="image-viewer-tile">
        <img src="...-diff" title="Difference">
      </div>
    </div>
  */
  createDom(imgInfo, owner) {
    this.importStyle();
    this.viewer = document.createElement("div");
    // Viewer DOM API
    this.viewer.showImages = this.showImages.bind(this);
    this.viewer.owner = owner;
    this.viewer.controller = this;
    this.viewer.classList.add("image-viewer");
    this.viewer.addEventListener("click", (ev) => {
      this.toggleZoom({x: ev.offsetX, y: ev.offsetY}, ev.target);
    });
    for (let info of imgInfo) {
      let imgTile = document.createElement("div");
      imgTile.classList.add("image-viewer-tile");
      imgTile.classList.add("hidden");
      let img = document.createElement("img");
      img.src = info.src;
      img.title = info.title;
      if (info.src.startsWith("https://")) {
        // Allow canvas access to CORS images.
        img.setAttribute("crossorigin", "anonymous");
      }
      imgTile.appendChild(img);
      imgTile.addEventListener("scroll", this.boundTileScrollHandler, {passive: true});
      imgTile.addEventListener("wheel", this.boundWheelHandler);
      this.viewer.appendChild(imgTile);
      if (info.title == "Difference") {
        this.diffImage = new Image();
        this.diffImage.addEventListener("load", function(ev) {
          this.computeDiffRect(ev.target);
        }.bind(this), false);
        this.diffImage.src = info.src;
      }
    }
    return this.viewer;
  }

  blinkDiffRect() {
    this.computeDiffRect(this.diffImage);
  }

  // public API
  showImages(sources) {
    let tiles = Array.from(this.viewer.querySelectorAll(".image-viewer-tile"));
    // Newly shown tiles should have the same scroll position as existing tiles.
    let scrollPosition;
    for (let tile of tiles) {
      if (!tile.classList.contains("hidden")) {
        scrollPosition = {top: tile.scrollTop, left: tile.scrollLeft};
        break;
      }
    }
    for (let tile of tiles) {
      let tileImage = tile.querySelector("img");
      let visible = sources.includes(tileImage.src);
      if (visible) {
        tile.classList.remove("hidden");
        if (scrollPosition) {
          tile.scrollTop = scrollPosition.top;
          tile.scrollLeft = scrollPosition.left;
        }
      }
      else
        tile.classList.add("hidden");
    }
  }

  findVisibleImage(preferredImage) {
    if (preferredImage && !preferredImage.parentNode.classList.contains("hidden"))
      return preferredImage;
    for (let image of Array.from(this.viewer.querySelectorAll("img")))
      if (!image.parentNode.classList.contains("hidden") && image)
        return image;
  }

  tileScrollHandler(ev) {
    let sourceTile = ev.target;
    if (sourceTile.classList.contains("hidden"))
      return;
    for (let tile of Array.from(this.viewer.querySelectorAll(".image-viewer-tile"))) {
      if (tile != sourceTile) {
        tile.scrollTop = sourceTile.scrollTop;
        tile.scrollLeft = sourceTile.scrollLeft;
      }
    }
  }

  tileWheelHandler(ev) {
    // Prevent page back/forward gestures when scrolling inside tiles.
    if (Math.abs(ev.deltaY) > Math.abs(ev.deltaX))
      return;
    let target = ev.currentTarget;
    let blockScrollRight = (target.scrollLeft + target.clientWidth) == target.scrollWidth;
    let blockScrollLeft = target.scrollLeft == 0;
    if (blockScrollRight && ev.deltaX > 0) {
      ev.preventDefault();
    }
    if (blockScrollLeft && ev.deltaX < 0) {
      ev.preventDefault();
    }
  }

  eventMoveHandler(ev) {
    let imageRect = this.findVisibleImage(this.zoomTarget).getBoundingClientRect();
    let zoom = this.viewer.querySelector(".image-viewer-zoom");
    if (zoom)
      zoom.showLocation({
      x: ev.pageX - (imageRect.left +  window.scrollX),
      y: ev.pageY - (imageRect.top + window.scrollY)
    });
  }

  toggleZoom(location, zoomTarget) {
    let zoom = this.viewer.querySelector(".image-viewer-zoom");
    if (zoom) {
      zoom.remove();
      delete this.zoomTarget;
      this.viewer.removeEventListener("mousemove", this.boundEventMoveHandler);
      this.viewer.removeEventListener("touchmove", this.boundEventMoveHandler);
    } else {
      this.zoomTarget = zoomTarget;
      let zoomElement = (new ImageViewerZoom()).createDom(this.viewer, this.viewer.querySelectorAll("img"));
      this.viewer.addEventListener("mousemove", this.boundEventMoveHandler);
      this.viewer.addEventListener("touchmove", this.boundEventMoveHandler);
      zoomElement.showLocation(location);
    }
  }

  computeDiffRect(image) {
    let canvas = document.createElement("canvas");
    canvas.width = image.naturalWidth;
    canvas.height = image.naturalHeight;
    let ctx = canvas.getContext("2d");
    ctx.drawImage(image, 0, 0);
    let top = image.naturalHeight;
    let bottom = 0;
    let left = image.naturalWidth;
    let right = 0;
    try {
      let imageData = ctx.getImageData(0, 0, image.naturalWidth, image.naturalHeight);
      for (let x = 0; x < imageData.width; x++)
        for (let y = 0; y < imageData.height; y++) {
          let offset  = y * imageData.width * 4 + x * 4;
          if (imageData.data[offset] == 255 && imageData.data[offset+1] == 0 && imageData.data[offset+2] == 0) {
            if (x < left) left = x;
            if (x > right) right = x;
            if (y < top) top = y;
            if (y > bottom) bottom = y;
          }
        }
      if (bottom != 0 && right != 0)
        this.animateDiffRect({top: top, left: left, width: Math.max(0, right - left), height: Math.max(0, bottom - top)}, image);
    } catch(ex) {
      console.error("cannot show error rect on local files because of cross origin taint");
    }
  }

  animateDiffRect(diffRect, image) {
    if (!image)
      return;
    if (!image.complete) {
      image.addEventListener("load", _ => this.animateDiffRect(diffRect, image));
      return;
    }
    let highlight = document.createElement("div");
    highlight.classList.add("image-viewer-highlight");
    highlight.style.top = image.offsetTop + diffRect.top + "px";
    highlight.style.left = image.offsetLeft + diffRect.left  + "px";
    highlight.style.width = Math.max(5, diffRect.width) + "px";
    highlight.style.height = Math.max(5, diffRect.height) + "px";
    this.viewer.appendChild(highlight);
    highlight.addEventListener("animationend", _ => highlight.remove());
    if (diffRect.width < 100 || diffRect.height < 100) {
      highlight.classList.add("animate-long");
    }
    highlight.classList.add("animate");
  }
} // class ImageResultsViewer

class ImageViewerZoom {
  constructor(zoomFactor = 6) {
    this.zoomFactor = zoomFactor;
  }

  importStyle() {
    if (document.querySelector("#ImageViewerZoomCSS"))
      return;
    let style = document.createElement("style");
    style.setAttribute("type", "text/css");
    style.setAttribute("id", "ImageViewerZoomCSS");
    style.innerText = `
    .image-viewer-zoom {
      display: flex;
      background-color: #555;
      position: fixed;
      padding-left: 4px;
      padding-right: 4px;
      top: 20px;
      left: 16px;
      bottom: 16px;
      right: 16px;
      height: calc((100vw - 32px) / 3);
      max-height: 50vh;
      box-shadow: 0px 0px 10px 2px rgba(0,0,0,0.68);
    }
    .image-viewer-zoom-tile {
      position: relative;
      flex-grow: 1;
      flex-shrink: 1;
      flex-basis: 100px;
      margin: 16px 8px 16px 8px;
    }
    .image-viewer-zoom-tile > canvas {
      width: 100%;
      height: 100%;
    }
    .image-viewer-zoom-tile > .title {
      position: absolute;
      top: -36px;
      left: -px;
      background-color: white;
      white-space: nowrap;
      overflow: hidden;
    }
    .image-viewer-zoom-color {
      font-size: smaller;
    }
    `;
    document.head.appendChild(style);
  }
  /*
    <div class="image-viewer-zoom">
      <div class="image-viewer-zoom-tile"> // repeat for each image
        <canvas></canvas>
        <div class="title"><span class="color"></span></div>
      </div>
  */
  createDom(container, images) {
    this.importStyle();
    this.images = Array.from(images);
    this.zoomElement = document.createElement("div");
    // ImageViewer DOM API
    this.zoomElement.showLocation = this.showLocation.bind(this);

    this.zoomElement.classList.add("image-viewer-zoom");
    this.zoomElement.classList.add("close-on-esc");
    container.appendChild(this.zoomElement);
    // Add canvas for each image.
    for (let img of this.images) {
      let tile = document.createElement("div");
      tile.classList.add("image-viewer-zoom-tile");
      tile.innerHTML = `<canvas></canvas><div class="title">${img.title} <span class="image-viewer-zoom-color"></span></div>`;
      this.zoomElement.appendChild(tile);
    }
    return this.zoomElement;
  }

  showLocation(location) {
    let canvases = Array.from(this.zoomElement.querySelectorAll("canvas"));
    let naturalSize;
    // Find non-hidden image to determine size.
    for (let image of this.images) {
      if (image.complete && !image.classList.contains("hidden")) {
        naturalSize = {width: image.naturalWidth, height: image.naturalHeight};
        break;
      }
    }
    if (!naturalSize) {
      console.warn("image not loaded");
      return;
    }
    for (let i = 0; i < canvases.length; i++) {
      // Set canvas to right size if window resized.
      let canvasWidth = canvases[i].clientWidth;
      let canvasHeight = canvases[i].clientHeight;
      canvases[i].width = canvasWidth;
      canvases[i].height = canvasHeight;
      let ctx = canvases[i].getContext("2d");
      ctx.imageSmoothingEnabled = false;
      // Copy over zoomed image.
      let sWidth = canvasWidth / this.zoomFactor;
      let sHeight = canvasHeight / this.zoomFactor;
      let pad = 20;
      let sx = Math.floor(Math.min(Math.max(-pad, location.x - sWidth / 2), naturalSize.width + pad - sWidth));
      let sy = Math.floor(Math.min(Math.max(-pad, location.y - sHeight / 2), naturalSize.height + pad - sHeight));

      ctx.drawImage(this.images[i], sx, sy, sWidth, sHeight, 0, 0, canvasWidth, canvasHeight);
      // Draw grid.
      ctx.strokeStyle = "rgba(0,0,0,0.05)";
      let pixelSize = canvasWidth / sWidth;
      ctx.beginPath();
      for (let y = 1; y < sHeight; y++) {
        ctx.moveTo(0, y * pixelSize);
        ctx.lineTo(canvasWidth, y * pixelSize);
      }
      for (let x = 1; x < sWidth; x++) {
        ctx.moveTo(x*pixelSize, 0);
        ctx.lineTo(x*pixelSize, canvasHeight);
      }
      ctx.closePath();
      ctx.stroke();

      // Highlight middle pixel whose color is measured.

      try { // getImageData throws on local file system.
        let middleX = Math.floor(sWidth / 2);
        let middleY = Math.floor(sHeight / 2);
        let imageData = ctx.getImageData(middleX * pixelSize + 2, middleY * pixelSize + 2, 1, 1);
        let r = imageData.data[0];
        let g = imageData.data[1];
        let b = imageData.data[2];
        let a = imageData.data[3];
        let colorSpan = canvases[i].parentNode.querySelector(".image-viewer-zoom-color");
        let color = `rgba(${r}, ${g}, ${b}, ${a})`;
        colorSpan.innerText = `(${sx + middleX}, ${sy + middleY}) ${color}`;
        colorSpan.style.backgroundColor = color;
        colorSpan.style.color =  ((r + g + b) > 300 ) ? "black" : "white";
        ctx.beginPath();
        ctx.moveTo(middleX * pixelSize, middleY * pixelSize);
        ctx.lineTo((middleX + 1) * pixelSize, middleY * pixelSize);
        ctx.lineTo((middleX + 1) * pixelSize, (middleY + 1) * pixelSize);
        ctx.lineTo(middleX * pixelSize, (middleY + 1) * pixelSize);
        ctx.lineTo(middleX * pixelSize, middleY * pixelSize);
        ctx.strokeStyle = "rgba(0,0,0,0.2)";
        ctx.closePath();
        ctx.stroke();
      } catch(ex) {}
    }
  }
} // class ImageViewerZoom

class TextResultsToolbar extends TXToolbar {
  constructor() {
    super();
  }

  defaultExtendSelection(anchor) {
    return anchor.className == "repaint";
  }

  createDom(test) {
    this.importStyle();
    let pathParser = new PathParser(test.expectPath);
    this.toolbar = document.createElement("li");
    this.toolbar.showDefault = (_ => {
      this.selectAnchor(
        this.toolbar.querySelector("a.pretty")  || this.toolbar.querySelector("a.actual"));
      let repaintOverlay = this.toolbar.querySelector("a.repaint");
      if (repaintOverlay)
        this.selectAnchor(repaintOverlay, true);
    }).bind(this);
    this.toolbar.classList.add("text-results-toolbar");
    this.toolbar.classList.add("tx-toolbar");
    this.toolbar.appendChild(document.createTextNode("text:"));
    this.toolbar.appendChild(this.createAnchor(
        "pretty", pathParser.resultLink("-pretty-diff.html"), "Pretty difference", "pretty"));
    this.toolbar.appendChild(this.createAnchor(
        "actual", pathParser.resultLink("-actual.txt"), "Actual text", "actual"));
    this.toolbar.appendChild(this.createAnchor(
        "expected", pathParser.resultLink("-expected.txt"), "Expected result", "expected"));
    this.toolbar.appendChild(this.createAnchor(
        "diff", pathParser.resultLink("-diff.txt"), "Difference", "diff"));
    if (test.has_repaint_overlay) {
      this.toolbar.appendChild(this.createAnchor(
          "repaint", pathParser.repaintOverlayLink(), "Repaint overlay", "repaint"));
    }
    this.toolbar.addEventListener("click", ev => {
      if (ev.target.tagName == "A") {
        this.selectAnchor(ev.target);
        ev.preventDefault();
      }
    });
    return this.toolbar;
  }

  updateViewer() {
    // Find currently selected anchor.
    let selectedAnchors = Array.from(this.toolbar.querySelectorAll("a[data-selected]"));
    if (selectedAnchors.length == 0) {
      selectedAnchors.push(this.toolbar.querySelector("a"));
      selectedAnchors[0].setAttribute("data-selected", "");
    }
    let viewer = this.getViewer();
    if (!viewer) {
      let fileInfo = this.getAnchorInfo("a");
      if (!fileInfo)
        return;
      viewer = (new TextFileViewer()).createDom(fileInfo, this);
      GUI.setResultViewer(this.toolbar, viewer);
    }
    viewer.showFile(selectedAnchors.map( anchor => anchor.href));
  }
} // class TextResultsToolbar

class TextFileViewer {
  importStyle() {
    if (document.querySelector("#TextFileViewerCSS"))
      return;
    let style = document.createElement("style");
    style.setAttribute("type", "text/css");
    style.setAttribute("id", "TextFileViewerCSS");
    style.innerText =
    ` .text-file-viewer {
      position: relative;
      display: flex;
    }
    .text-file-viewer > iframe {
      flex-shrink: 1;
      flex-grow: 1;
      border: 1px solid #888;
      overflow: scroll;
      max-height: calc(100vh - 80px);
      margin-right: 8px;
      height: 600px;
    }
    `;
    document.head.appendChild(style);
  }

  /* TextFileViewer dom
    <div class="text-file-viewer">
      <iframe class="text-file-viewer-iframe" src="file..">
      <iframe class="text-file-viewer-iframe" src="file..">
      <iframe class="text-file-viewer-iframe" src="file..">
    </div>
  */
  createDom(fileInfo, owner) {
    this.importStyle();
    this.viewer = document.createElement("div");
    // TextFileViewer DOM API
    this.viewer.owner = owner;
    this.viewer.showFile = this.showFile.bind(this);

    this.viewer.classList.add("text-file-viewer");
    for (let info of fileInfo) {
      let iframe = document.createElement("iframe");
      iframe.classList.add("text-file-viewer-iframe");
      iframe.classList.add("hidden");
      iframe.src = info.src;
      iframe.setAttribute("tabindex", -1);
      this.viewer.appendChild(iframe);
    }
    return this.viewer;
  }

  showFile(sources) {
    let iframes = Array.from(this.viewer.querySelectorAll(".text-file-viewer-iframe"));
    for (let iframe of iframes) {
      let visible = sources.includes(iframe.src);
      if (visible) {
        iframe.classList.remove("hidden");
      } else {
        iframe.classList.add("hidden");
      }
    }
  }
} // class TextFileViewer

class PlainHtmlToolbar extends TXToolbar {
  createDom(html) {
    super.importStyle();
    let dom = document.createElement("li");
    dom.classList.add("tx-toolbar");
    dom.innerHTML = html;
    return dom;
  }
} // class PlainHtmlToolbar

class SimpleLinkToolbar extends TXToolbar {
  createDom(linkOrPromise, title, innerHTML) {
    super.importStyle();
    this.toolbar = document.createElement("li");
    // Toolbar DOM API
    this.toolbar.showDefault = (_ => {this.selectAnchor(this.toolbar.querySelector("a"));}).bind(this);

    this.toolbar.classList.add("tx-toolbar");
    this.toolbar.appendChild(this.createAnchor("", linkOrPromise, title, innerHTML));
    this.toolbar.addEventListener("click", ev => {
      if (ev.target.tagName == "A") {
        this.selectAnchor(ev.target);
        ev.preventDefault();
      }
    });
    return this.toolbar;
  }

  updateViewer() {
    // Find currently selected anchor.
    let selectedAnchors = Array.from(this.toolbar.querySelectorAll("a[data-selected]"));
    if (selectedAnchors.length == 0) {
      selectedAnchors.push(this.toolbar.querySelector("a"));
      selectedAnchors[0].setAttribute("data-selected", "");
    }
    let viewer = this.getViewer();
    if (!viewer) {
      let fileInfo = this.getAnchorInfo("a");
      if (!fileInfo)
        return;
      viewer = (new TextFileViewer()).createDom(fileInfo, this);
      GUI.setResultViewer(this.toolbar, viewer);
    }
    viewer.showFile(selectedAnchors.map( anchor => anchor.href));
  }
} // class SimpleLinkToolbar

// Audio results.
class AudioResultsToolbar extends TXToolbar {

  createDom(test) {
    this.importStyle();
    this.test = test;
    let pathParser = new PathParser(test.expectPath);
    this.toolbar = document.createElement("li");
    this.toolbar.showDefault = (_ => {
      this.selectAnchor(this.toolbar.querySelector("a"));
    }).bind(this);
    this.toolbar.classList.add("tx-toolbar");
    this.toolbar.innerHTML = `<a href="#audioresults">audio results</a>\n`;
    this.toolbar.addEventListener("click", ev => {
      if (ev.target.tagName == "A") {
        this.selectAnchor(ev.target);
        ev.preventDefault();
      }
    });
    return this.toolbar;
  }

  updateViewer() {
    let viewer = this.getViewer();
    if (!viewer) {
      viewer = (new AudioResultsViewer()).createDom(this.test);
      GUI.setResultViewer(this.toolbar, viewer);
    }
  }
}

class AudioResultsViewer {
  createDom(test) {
    this.viewer = document.createElement("div");
    this.viewer.classList.add("audio-results-viewer");
    let pathParser = new PathParser(test.expectPath);
    this.viewer.innerHTML = `
      <p>Actual:
        <audio class="actual" controls></audio>
        <a class="actual" download="actual.wav"">download</a>
      </p>
      <p>Expected:
        <audio class="expected" controls></audio>
        <a class="expected" download="expected.wav">download</a>
      </p>
    `;
    pathParser.resultLink('-actual.wav').then((href) => {
      this.viewer.querySelector("audio.actual").src = href;
      this.viewer.querySelector("a.actual").href = href;
    });
    pathParser.resultLink('-expected.wav').then((href) => {
      this.viewer.querySelector("audio.expected").src = href;
      this.viewer.querySelector("a.expected").href = href;
    });
    return this.viewer;
  }
} // class AudioResultsViewer

(function setTextFilterWidth() {
  let textFilter = document.querySelector("#text-filter");
  let div = document.createElement('div');
  div.style.position = 'absolute';
  div.style.top = '-200px';
  div.style.fontSize = getComputedStyle(textFilter).fontSize;
  div.textContent = textFilter.getAttribute('placeholder');
  document.body.appendChild(div);
  textFilter.style.width = div.offsetWidth + 30 + 'px';
})();
</script>
<script>
(function() {
  // Initialization. Requirements are:
  // 1. For local usage, results.html is expected to be in layout-test-results
  //    directory which contains full_results_jsonp.js and test result
  //    artifacts.
  // 2. For bot usage, a link to results.html should have a parameter:
  //      ?json=<URI to full_results_jsonp.js>
  //    If the full_results_jsonp.js calls SET_TASK_IDS(<list of task_ids>),
  //    we'll fetch test result artifacts from ResultDB through the
  //    QueryArtifacts rpc interface. The task ids are ids of the swarming
  //    tasks of the corresponding web tests step that produced the test
  //    that produced the test results. They are used to construct
  //    invocation ids of the corresponding web tests step.
  //    If there is no SET_TASK_IDS() in full_results_jsonp.js, we'll expect
  //    test result artifacts to be in the same directory of
  //    full_results_jsonp.js.
  //
  let jsonpUrl = 'full_results_jsonp.js';
  if (location.search) {
    let matches = location.search.match(/\?json=([^&]*)/);
    if (matches[1])
      jsonpUrl = decodeURIComponent(matches[1]);
  }
  let slashPos = jsonpUrl.lastIndexOf('/');
  if (slashPos != -1) {
    let base = document.createElement('base');
    base.href = jsonpUrl.substring(0, slashPos + 1);
    document.head.appendChild(base);
  }

  // jsonp callbacks
  window.ADD_FULL_RESULTS = function(results) {
    GUI.initPage(results);
  };
  window.SET_TASK_IDS = function(taskIds) {
    PathParser.setTaskIds(taskIds);
  };

  let script = document.createElement('script');
  script.src = jsonpUrl.substring(slashPos + 1);
  script.onerror = () => {
    let message = `Fail to load ${jsonpUrl}.<br>` +
                  'This may be because the web test step has not finished yet';
    if (location.search) {
      let oldResults =
          `https://test-results.appspot.com/data/layout_results/${jsonpUrl.substring(0, slashPos)}/layout-test-results/results.html`;
      message += ', or the results are in the old format which can be' +
                 ` accessed through <a href="${oldResults}">this link</a>.`;
    } else {
      message += '.';
    }
    document.write(message);
  };
  document.body.appendChild(script);
})();
</script>
